package za.co.yellowfire.solarflare;

import org.apache.commons.beanutils.ConvertUtils;
import org.apache.derby.vti.Restriction;
import org.apache.solr.client.solrj.SolrRequest;
import org.apache.solr.client.solrj.SolrServer;
import org.apache.solr.client.solrj.impl.CommonsHttpSolrServer;
import org.apache.solr.client.solrj.response.QueryResponse;
import org.apache.solr.client.solrj.response.SolrPingResponse;
import org.apache.solr.client.solrj.response.SpellCheckResponse;
import org.apache.solr.client.solrj.response.UpdateResponse;
import org.apache.solr.common.SolrDocument;
import org.apache.solr.common.SolrDocumentList;
import org.apache.solr.common.SolrInputDocument;
import org.apache.solr.common.params.CommonParams;
import org.apache.solr.common.params.FacetParams;
import org.apache.solr.common.params.ModifiableSolrParams;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import za.co.yellowfire.solarflare.annotation.*;
import za.co.yellowfire.solarflare.log.LogMsgId;
import za.co.yellowfire.solarflare.log.LogType;

import javax.enterprise.event.Observes;
import javax.inject.Inject;
import javax.inject.Named;
import java.lang.annotation.Annotation;
import java.lang.reflect.Field;
import java.net.MalformedURLException;
import java.util.*;

/**
 * @author Mark P Ashworth
 * @version 0.1
 */
@Named("SearchManager")
public class SearchManagerBean implements SearchManager {
    private static final long serialVersionUID = 1L;
    private static final Logger LOGGER = LoggerFactory.getLogger(LogType.SEARCH.getCategory());

    @Inject @Solr @Url
    private String url;

    private SolrServer solr;

    /**
     * Default constructor
     */
    public SearchManagerBean() { }

    /**
     * Constructs the search manager with the SolrServer instance to be used.
     * @param server The SolrServer instance
     */
    public SearchManagerBean(SolrServer server) {
        this.solr = server;
    }

    /**
     * Constructs the search manager with the url to the remote SolrServer, i.e. http://localhost:8080/solr
     * @param url The URL to the remote Solr server
     */
    public SearchManagerBean(String url) {
        this.url = url;
    }

    /**
     * The url of the remote Solr server
     * @return The url
     */
    public String getUrl() {
        //TODO SOLARFLARE-1 : SearchProvider does not always inject the URL into SearchManager
        LOGGER.info("Injected Apache Solr url is " + url);
        if (url == null) {
            LOGGER.info("Issue SOLARFLARE-1 : Injected Apache Solr url is null, reading directly");
            url = new SearchProvider().provideSearchUrl();
        }
        return url;
    }

    /**
     * The url of the remote Solr server
     * @param url The new url of the Solr server
     */
    public void setUrl(String url) {
        this.url = url;
    }

    /**
     * The instance of the Solr server
     * @return The Solr server instance
     * @throws MalformedURLException If there was an error creating the Solr server instance from the url
     */
    protected SolrServer getSolr() throws MalformedURLException {
        if (solr == null) {
            solr = new CommonsHttpSolrServer(getUrl());
        }
        return solr;
    }

    /**
     * Observes when searchable objects are added.
     * @param object The object to add to the index
     */
    public void onSearchableAdded(@Observes @SearchableAdded Object object) {

        SolrInputDocument doc = new SolrInputDocument();

        try {
            encode(doc, object);

            if (!doc.isEmpty()) {
                SolrPingResponse ping = getSolr().ping();
                LOGGER.debug("Solr ping status = {}", ping.getStatus());

                UpdateResponse response = getSolr().add(doc);
                if (response.getResponse() != null) {
                    LOGGER.debug("Solr response:");
                    for (Map.Entry<String,Object> entry : response.getResponse()) {
                        LOGGER.debug("Solr entry : {} = {}", entry.getKey(), entry.getValue());
                    }
                }
                getSolr().commit();
            } else {
                LOGGER.info("Not adding domain object because no searchable properties found");
            }
        } catch (Exception e) {
            LOGGER.error("Unable to add objects to search index", e);
        }
	}

    /**
     * Observes when searchable objects are added.
     * @param objects The list of objects to add to the index
     */
    public void onSearchablesAdded(@Observes @SearchablesAdded Collection<?> objects) {

        SolrInputDocument doc;

        try {
            boolean commit = false;
            for (Object o : objects) {
                doc = new SolrInputDocument();
                encode(doc, o);
                if (!doc.isEmpty()) {
                    commit = true;
                    SolrPingResponse ping = getSolr().ping();
                    LOGGER.debug("Solr ping status = {}", ping.getStatus());

                    UpdateResponse response = getSolr().add(doc);
                    if (response.getResponse() != null) {
                        LOGGER.debug("Solr response:");
                        for (Map.Entry<String,Object> entry : response.getResponse()) {
                            LOGGER.debug("Solr entry : {} = {}", entry.getKey(), entry.getValue());
                        }
                    }
                } else {
                    LOGGER.info("Not adding domain object because no searchable properties found");
                }
            }

            if (commit) {
                UpdateResponse response = getSolr().commit();
                if (response.getResponse() != null) {
                    LOGGER.debug("Solr response:");
                    for (Map.Entry<String,Object> entry : response.getResponse()) {
                        LOGGER.debug(entry.getKey() + " = " + entry.getValue());
                    }
                }
            }
        } catch (Exception e) {
            LOGGER.error("Unable to add objects to search index", e);
        }
	}

    /**
     * Observes when searchable objects are removed.
     * @param object The object to add to the index
     */
    public void onSearchableRemoved(@Observes @SearchableRemoved Object object) {

        String id = null;

        Searchable searchable = object.getClass().getAnnotation(Searchable.class);
        if (searchable == null) {
            LOGGER.info("The domain entity is not searchable " + object);
            return;
        }
        
        Field[] fields =  object.getClass().getDeclaredFields();
        for (Field field : fields) {
            SearchablePropertyId property = field.getAnnotation(SearchablePropertyId.class);
            if (property != null) {
                Object value = null;
                try {
                    field.setAccessible(true);
                    value = field.get(object);
                } catch (IllegalAccessException e) {
                    LOGGER.warn("Unable to access property {} : {}", field.getName(), e.getMessage());
                }

                if (value == null) {
                    LOGGER.debug("The objects id is null {}", object != null ? object : "null");
                    return;
                }

                id = searchable.name() + ":" + value.toString();
                break;
            }
        }

        try {
            if (id != null) {
                getSolr().deleteById(id);
                getSolr().commit();
            } else {
                LOGGER.info("Not deleting domain object because no searchable id property found");
            }
        } catch (Exception e) {
            LOGGER.error("Unable to remove object by id {} : {}", id != null ? id : "null", e.getMessage());
        }
	}

    /**
     * Searches for instances of the domain class using the value as the search criteria.
     * @param query The search query
     * @return List of search results
     */
    @Override
    public SearchResult search(SearchQuery query) {

        SearchResult result = new SearchResult();

        try {
            ModifiableSolrParams params = new ModifiableSolrParams();
            params.add(CommonParams.Q, query.getQuery());
            params.add(CommonParams.FL, "*,score");
            params.add(FacetParams.FACET, query.isFacetOn() ? "on" : "off");

            switch (query.getOperator()) {
            case AND: params.add(CommonParams.Q + ".op", "AND");
            }
            if (query.getDomainClass() != null) {
                params.add(CommonParams.FQ, "class:" + query.getDomainClass().getCanonicalName());
            }
            if (query.isDebug()) {
                params.add(CommonParams.DEBUG_QUERY, query.isDebug() ? "on" : "off");
            }
            
            QueryResponse response = getSolr().query(params, SolrRequest.METHOD.GET);
            if (response != null) {

                SolrDocumentList docs = response.getResults();
                if (docs != null && !docs.isEmpty()) {
                    for (SolrDocument doc : docs) {
                        Object o = resolve(query.getDomainClass(), doc);
                        if (o != null) {
                            result.add(o);
                        }
                    }
                }

                SpellCheckResponse spellCheck = response.getSpellCheckResponse();
                if (spellCheck != null) {
                    for(SpellCheckResponse.Suggestion suggestion : spellCheck.getSuggestions()) {
                        result.getSuggestions().add(new SearchSuggestion(suggestion.getToken(), suggestion.getAlternatives()));
                    }
                }
            }
        } catch (Exception e) {
            String error = e.getMessage();
            LOGGER.error("Unable to search for class {} search text {} from Solr {} : {}",
                    new Object[] {
                            query.getDomainClass() != null ? query.getDomainClass().getCanonicalName() : "null",
                            query.getQuery() != null ? query.getQuery() : "null",
                            this.url != null ? this.url : "null",
                            error != null ? error : "null"
                    });
            LOGGER.error("Unable to search", e);
        }
        return result;
    }

    /**
     * Resolves the domain class from the search document
     * @param domainClass The domain class
     * @param doc The search document
     * @return The instance of the domain class resolved from the search document
     */
    protected Object resolve(Class domainClass, SolrDocument doc) {
        if (domainClass == null) {
            LOGGER.warn("{} : The domain class is null", LogMsgId.NO_DOMAIN_CLASS_IN_SEARCH);
            return null;
        }
        if (!doc.containsKey("class")) {
            LOGGER.warn("{} : The search document does not contain field class", LogMsgId.NO_CLASS_FIELD);
            return null;
        }
        if (!doc.get("class").equals(domainClass.getCanonicalName())) {
            
            LOGGER.warn("{} : The search document class {} does not match domain class {}",
                    new Object[] {
                            LogMsgId.DOC_CLASS_DOES_NOT_MATCH,
                            doc.get("class"),
                            domainClass.getCanonicalName()});

            return null;
        }

        try {
            return decode(doc, domainClass.newInstance() /*Class.forName((String) doc.get("class")).newInstance()*/);
        //} catch (ClassNotFoundException e) {
        //    LOGGER.error("{} : The search document class cannot be found", LogMsgId.CLASS_NOT_FOUND);
        } catch (IllegalAccessException e) {
            LOGGER.error("{} : The search document class cannot be created because of illegal access", LogMsgId.ILLEGAL_ACCESS);
        } catch (InstantiationException e) {
            LOGGER.error("{} : The search document class cannot be created because of instantiation exception", LogMsgId.INSTANTIATION_EXCEPTION);
        }
        return null;
    }

    /**
     * Encodes the domain class into a search document
     * @param doc The search input document
     * @param object The object to encode
     */
    protected void encode (SolrInputDocument doc, Object object) {

        Searchable searchable = object.getClass().getAnnotation(Searchable.class);
        if (searchable == null && doc.get("id") == null) {
            LOGGER.info("The object is not searchable {} or is not a property of a searchable object.", object != null ? object : object);
            return;
        }
        
        Field[] fields =  object.getClass().getDeclaredFields();
        for (Field field : fields) {
            SearchablePropertyId property = field.getAnnotation(SearchablePropertyId.class);
            if (property != null) {
                Object id = null;
                try {
                    field.setAccessible(true);
                    id = field.get(object);
                } catch (IllegalAccessException e) {
                    LOGGER.error("Searchable property id on field {} is unaccessable", field);
                }

                if (id == null) {
                    LOGGER.warn("The objects id is null {} ",object);
                    return;
                }

                doc.addField("id", searchable.name() + property.separator() + id.toString());
                doc.addField("class", object.getClass().getName());
                break;
            }
        }

        for (Field field : fields) {
            SearchableProperty property = field.getAnnotation(SearchableProperty.class);
            if (property != null) {

                String name = property.name();
                if (name.equals("")) {
                    name = field.getName();
                }

                /*
                If the property is dynamic then the property name
                needs to be appended with one of the Solr extentions
                to indicate that it is dynamic
                <dynamicField name="*_i"  type="int"    indexed="true"  stored="true"/>
                <dynamicField name="*_s"  type="string"  indexed="true"  stored="true"/>
                <dynamicField name="*_l"  type="long"   indexed="true"  stored="true"/>
                <dynamicField name="*_t"  type="text"    indexed="true"  stored="true"/>
                <dynamicField name="*_txt" type="text"    indexed="true"  stored="true" multiValued="true"/>
                <dynamicField name="*_b"  type="boolean" indexed="true"  stored="true"/>
                <dynamicField name="*_f"  type="float"  indexed="true"  stored="true"/>
                <dynamicField name="*_d"  type="double" indexed="true"  stored="true"/>
                <dynamicField name="*_coordinate"  type="tdouble" indexed="true"  stored="false"/>
                <dynamicField name="*_dt" type="date"    indexed="true"  stored="true"/>
                <dynamicField name="*_p"  type="location" indexed="true" stored="true"/>
                */

                SearchablePropertyType type = property.type();
                if (type == SearchablePropertyType.INFER) {
                    Class clazz = field.getType();
                    if (clazz.isAssignableFrom(Integer.class)) { type = SearchablePropertyType.INT; }
                    else if (clazz.isAssignableFrom(String.class)) { type = SearchablePropertyType.STRING; }
                    else if (clazz.isAssignableFrom(Long.class)) { type = SearchablePropertyType.LONG; }
                    else if (clazz.isAssignableFrom(Boolean.class)) { type = SearchablePropertyType.BOOLEAN; }
                    else if (clazz.isAssignableFrom(Float.class)) { type = SearchablePropertyType.FLOAT; }
                    else if (clazz.isAssignableFrom(Double.class)) { type = SearchablePropertyType.DOUBLE; }
                    else if (clazz.isAssignableFrom(Date.class)) { type = SearchablePropertyType.DATE; }
                    else if (clazz.isAssignableFrom(java.sql.Date.class)) { type = SearchablePropertyType.DATE; }
                    else {
                        try {
                            field.setAccessible(true);
                            Object value = field.get(object);
                            if (value != null) {
                                encode(doc, value);
                            }
                            continue;
                        } catch (IllegalAccessException e) {
                            LOGGER.error("{} : Unable to acess property {} : {}", new Object[] {LogMsgId.ILLEGAL_ACCESS, field, e.getMessage()});
                        }
                    }
                }

                if (property.dynamic()) {
                    switch (type) {
                    case INT: name += "_i"; break;
                    case STRING: name += "_s"; break;
                    case LONG: name += "_l"; break;
                    case TEXT: name += "_t"; break;
                    case MULTIVALUED_TEXT: name += "_txt"; break;
                    case BOOLEAN: name += "_b"; break;
                    case FLOAT: name += "_f"; break;
                    case DOUBLE: name += "_d"; break;
                    case COORDINATE: name += "_coordinate"; break;
                    case DATE: name += "_dt"; break;
                    case LOCATION: name += "_p"; break;
                    }
                }

                try {
                    field.setAccessible(true);
                    Object value = field.get(object);
                    doc.addField(name, value);
                    LOGGER.debug("Document.addField name = {} : {}", name, value != null ? value : "null");
                } catch (IllegalAccessException e) {
                    LOGGER.warn("Unable to get value for field {} : {}", field.getName(), e.getMessage());
                }
            }
        }
    }

    /**
     * Decodes the object from a search document
     * @param doc The search document
     * @param object The object instance
     * @return The object instance decoded from the search document
     */
	protected Object decode(SolrDocument doc, Object object) {
	
		Field[] fields =  object.getClass().getDeclaredFields();
		for (Field field : fields) {

            String name = field.getName();
            String separator = SearchableDefaults.DEFAULT_SEPARATOR;
            SearchablePropertyType type = SearchablePropertyType.INFER;
            boolean searchable = false;
            boolean dynamic = false;
            boolean id = false;

            Annotation[] annotations = field.getAnnotations();
            for (Annotation annotation : annotations) {
                if (annotation.annotationType().equals(SearchableProperty.class)) {
                    searchable = true;

                    SearchableProperty property = (SearchableProperty) annotation;
                    if (property.name() != null && !property.name().equals(""))
                        name = property.name();

                    type = property.type();
                    dynamic = property.dynamic();
                    
                    break;

                } else if (annotation.annotationType().equals(SearchablePropertyId.class)) {
                    searchable = true;
                    id = true;

                    SearchablePropertyId property = (SearchablePropertyId) annotation;
                    if (property.name() != null && !property.name().equals(""))
                        name = property.name();

                    separator = property.separator();

                    //Fix : separator regular expression
                    if (separator.equals("|")) {
                        separator = "\\" + separator;
                    } else if (separator.equals(".")) {
                        separator = "\\" + separator;
                    }

                    break;
                }
            }

            if (searchable) {
                if (type == SearchablePropertyType.INFER) {
                    Class clazz = field.getType();
                    if (clazz.isAssignableFrom(Integer.class)) { type = SearchablePropertyType.INT; }
                    else if (clazz.isAssignableFrom(String.class)) { type = SearchablePropertyType.STRING; }
                    else if (clazz.isAssignableFrom(Long.class)) { type = SearchablePropertyType.LONG; }
                    else if (clazz.isAssignableFrom(Boolean.class)) { type = SearchablePropertyType.BOOLEAN; }
                    else if (clazz.isAssignableFrom(Float.class)) { type = SearchablePropertyType.FLOAT; }
                    else if (clazz.isAssignableFrom(Double.class)) { type = SearchablePropertyType.DOUBLE; }
                    else if (clazz.isAssignableFrom(Date.class)) { type = SearchablePropertyType.DATE; }
                    else if (clazz.isAssignableFrom(java.sql.Date.class)) { type = SearchablePropertyType.DATE; }
                    else {
                        try {
                            field.setAccessible(true);
                            Object value = field.get(object);
                            if (value == null) {
                                value = field.getType().newInstance();
                            }
                            decode(doc, value);
                            field.set(object, value);
                            continue;
                        } catch (InstantiationException e) {
                            LOGGER.error("{} : Unable to instantiate property class {} : {}", new Object[] {LogMsgId.INSTANTIATION_EXCEPTION, field, e.getMessage()});
                        } catch (IllegalAccessException e) {
                            LOGGER.error("{} : Unable to acess property {} : {}", new Object[] {LogMsgId.ILLEGAL_ACCESS, field, e.getMessage()});
                        }
                    }
                }

                if (dynamic) {
                    switch (type) {
                    case INT: name += "_i"; break;
                    case STRING: name += "_s"; break;
                    case LONG: name += "_l"; break;
                    case TEXT: name += "_t"; break;
                    case MULTIVALUED_TEXT: name += "_txt"; break;
                    case BOOLEAN: name += "_b"; break;
                    case FLOAT: name += "_f"; break;
                    case DOUBLE: name += "_d"; break;
                    case COORDINATE: name += "_coordinate"; break;
                    case DATE: name += "_dt"; break;
                    case LOCATION: name += "_p"; break;
                    }
                }

                try {
                    Object value = doc.get(name);
                    if (id) {
                        String[] values = ((String) value).split(separator);
                        if (values.length > 1) {
                            value = values[values.length - 1];
                        }
                    }
                    field.setAccessible(true);
                    if (value != null) {
                        field.set(object, convertTo(value, field.getType()));
                    } else {
                        field.set(object, null);
                    }
                    
                    LOGGER.debug("name = {} : {}", field.getName(), field.get(object));
                } catch (IllegalAccessException e) {
                    LOGGER.warn("Unable to get value for field {} : {}", field.getName(), e.getMessage());
                }
            }
        }

        return object;
	}

    /**
     * Converts an object to a certain type
     * @param object The object to convert
     * @param type The type to convert to
     * @return The converted object
     */
    protected Object convertTo(Object object, Class type) {
        return ConvertUtils.convert(object, type);
    }
}
